<?
/**
 * Copyright 2007 Melange.
 *
 * This file is part of PHP-AAS.
 *
 * PHP-AAS is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * PHP-AAS is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with PHP-AAS; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
 *
 * @category    Melange
 * @package     php-http
 * @subpackage  core
 * @copyright   Copyright (c) 2007 Jeroen Simons. All rights reserved
 * @author      Jeroen Simons <jeroen@melange.nl>
 * @link        http://www.melange.nl/
 *
 */


class Context {


    /**
     * Logger
     */
    private $log;


    // ------------------------------------------------------------- Constants


    const ERROR = 1;


    const RESOURCES = "nl.melange.http.resources";


    /**
     * The request attribute under which we forward a Java exception
     * (as an object of type Throwable) to an error page.
     */
    const EXCEPTION_ATTR =
            "javax.servlet.error.exception";

    /**
     * The request attribute under which we forward the request URI
     * (as an object of type String) of the page on which an error occurred.
     */
    const EXCEPTION_PAGE_ATTR =
        "nl.melange.http.error.request_uri";

    /**
     * The request attribute under which we forward an HTTP status code
     * (as an object of type Integer) to an error page.
     */
    const STATUS_CODE_ATTR =
        "php.servlet.error.status_code";

    /**
     * The request attribute under which we forward an HTTP status message
     * (as an object of type STring) to an error page.
     */
    const ERROR_MESSAGE_ATTR =
        "php.servlet.error.message";

    /**
     * Request dispatcher path.
     */
    const DISPATCHER_REQUEST_PATH_ATTR =
        "nl.melange.php.http.core.DISPATCHER_REQUEST_PATH";

    /**
     * Request dispatcher state.
     */
    const DISPATCHER_TYPE_ATTR =
        "nl.melange.php.http.core.DISPATCHER_TYPE";


    // ----------------------------------------------------- Instance Variables


    /**
     * The context attributes for this context.
     */
    private $attributes = array();


    /**
     * Base path.
     */
    private $basePath = null;


    /**
     * The Realm with which this Container is associated.
     */
    protected $realm = null;


    /**
     * The security constraints for this web application.
     */
    private $constraints = array();


    /**
     * The status code error pages for this web application, keyed by
     * HTTP status code (as an Integer).
     */
    private $statusPages = array();


    /**
     * Special case: error page for status 200.
     */
    private $okErrorPage = null;


    /**
     * The exception pages for this web application, keyed by fully qualified
     * class name of the Java exception.
     */
    private $exceptionPages = array();



    private $authenticators = array("FORM"=>"FormAuthenticator");


    /**
     * The resources for this web application
     */
    private $resources = array();


    // ----------------------------------------------------- Constructors


    /**
     * Construct a new instance of this class, associated with the specified
     * Context instance.
     *
     * @param context The associated Context instance
     */
    function __construct($basePath, $contextPath) {

        $this->log = LoggerManager::getLogger("ApplicationContext");

        // Set the BasePath for this Application
        $this->basePath = $basePath;
        $this->path = $contextPath;
    }


    // ------------------------------------------------------------- Properties


    /**
     * Return the context path for this web application.
     */
    public function getPath() {
        return $this->path;
    }


    /**
     * Set the context path for this web application.
     *
     * @param path The new context path
     */
    public function setPath($path) {
        $this->path = $path;
    }


    /**
     * Return the value of the specified context attribute, if any;
     * otherwise return <code>null</code>.
     *
     * @param name Name of the context attribute to return
     */
    public function getAttribute($name) {
        return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : null;
    }


    /**
     * Return the value of the specified initialization parameter, or
     * <code>null</code> if this parameter does not exist.
     *
     * @param name Name of the initialization parameter to retrieve
     */
    public function getInitParameter($name) {
        return array_key_exists($name, $this->parameters) ? $this->parameters[$name] : null;
    }


    /**
     * Set a specified initialization parameter
     *
     * @param name Name of the initialization parameter to retrieve
     */
    public function setInitParameter($name, $value) {

        if($name == self::RESOURCES)
            define("RESOURCE_PATH", $value);

        $this->parameters[$name] = $value;
    }

    public function getInitParameterNames() {
        return array_keys($this->parameters);
    }


    /**
     * Return the real path for a given virtual path, if possible; otherwise
     * return <code>null</code>.
     *
     * @param path The path to the desired resource
     */
    public function getRealPath($path) {
        return ($this->basePath.$path);
    }


    /**
     * Bind the specified value with the specified context attribute name,
     * replacing any existing value for that name.
     *
     * @param name Attribute name to be bound
     * @param value New attribute value to be bound
     */
    public function setAttribute($name, $value) {

        // Name cannot be null
        if (is_null($name))
            throw new Exception("Name cannot be null");

        // Null value is the same as removeAttribute()
        if (is_null($value)) {
            $this->removeAttribute($name);
            return;
        }

        $oldValue = null;
        $replaced = false;

        $oldValue = array_key_exists($name, $this->attributes) ?
            $this->attributes[$name] : null;

        if (!is_null($oldValue))
            $replaced = true;

        $this->attributes[$name] = $value;
    }


    /**
     * Remove the context attribute with the specified name, if any.
     *
     * @param name Name of the context attribute to be removed
     */
    public function removeAttribute($name) {

        $value = null;
        $found = false;

        $found = array_key_exists($name, $this->attributes);
        if ($found) {
            unset($this->attributes[$name]);
        } else {
            return;
        }

    }


    /**
     * Return the Realm with which this Container is associated.  If there is
     * no associated Realm, return the Realm associated with our parent
     * Container (if any); otherwise return <code>null</code>.
     */
    public function getRealm() {

        if ($this->realm !== null)
            return ($this->realm);
        return (null);

    }


    /**
     * Set the Realm with which this Container is associated.
     *
     * @param realm The newly associated Realm
     */
    public function setRealm(RealmBase $realm) {

        $this->realm = $realm;

        // Start the new component if necessary
        if ($this->realm !== null)
            $this->realm->setApplicationContext($this);

    }


    /**
     * Return the login configuration descriptor for this web application.
     */
    public function getLoginConfig() {
        return $this->loginConfig;
    }


    /**
     * Set the login configuration descriptor for this web application.
     *
     * @param config The new login configuration
     */
    public function setLoginConfig(LoginConfig $config) {
        $this->loginConfig = $config;
    }


    /**
     * Add a security constraint to the set for this web application.
     */
    public function addConstraint(SecurityConstraint $constraint) {

        // Validate the proposed constraint
        $collections = $constraint->findCollections();
        for ($i = 0; $i < count($collections); $i++) {
            $patterns = $collections[$i]->findPatterns();
            for ($j = 0; $j < count($patterns); $j++) {
                $patterns[$j] = $this->adjustURLPattern($patterns[$j]);
                if (!$this->validateURLPattern($patterns[$j]))
                    throw new Exception
                        ("Invalid <url-pattern> ".$patterns[$j]." in security constraint");
            }
        }

        // Add this constraint to the set for our web application
        $this->constraints[] = $constraint;

    }


    /**
     * Add an error page for the specified error or Java exception.
     *
     * @param errorPage The error page definition to be added
     */
    public function addErrorPage(ErrorPage $errorPage) {
        // Validate the input parameters
        if ($errorPage === null)
            throw new Exception("ErrorPage cannot be null");
        $location = $errorPage->getLocation();
        if (($location !== null) && (strpos($location, "/") !== 0)) {
            if($this->log->isDebugEnabled())
                $this->log->debug("WARNING: Error page location ".$location." must start with a ''/''");
            $errorPage->setLocation("/" . $location);
        }

        // Add the specified error page to our internal collections
        $exceptionType = $errorPage->getExceptionType();
        if ($exceptionType !== null) {
            $this->exceptionPages[$exceptionType] = $errorPage;
        } else {
            if ($errorPage->getErrorCode() == 200) {
                $this->okErrorPage = $errorPage;
            }
            $this->statusPages[intval($errorPage->getErrorCode())] = $errorPage;
        }
    }


    /**
     * Return the security constraints for this web application.
     * If there are none, a zero-length array is returned.
     */
    public function findConstraints() {

        return ($this->constraints);

    }


    /**
     * Return the error page entry for the specified HTTP error code,
     * if any; otherwise return <code>null</code>.
     *
     * @param errorCode Error code to look up
     */
    public function findErrorPage($errorCode, $exceptionType = null) {
        
        if($exceptionType !== null)
            return array_key_exists($exceptionType, $this->exceptionPages)
                ? $this->exceptionPages[$exceptionType] : null;

        if ($errorCode == 200) {
            return ($this->okErrorPage);
        } else {
            return (array_key_exists($errorCode, $this->statusPages) ?
                $this->statusPages[intval($errorCode)] : null);
        }

    }


    /**
     * Return the set of defined error pages for all specified error codes
     * and exception types.
     */
    public function findErrorPages() {

        $results = array();
        $results += array_values($this->exceptionPages);
        $results += array_values($statusPages);
        return ($results);

    }


    /**
     * Set up an Authenticator automatically if required, and one has not
     * already been configured.
     */
    public function authenticate(Request $request, Response $response) {

        // Does this Context require an Authenticator?
        // Todo: Support Security Contraints!
        $constraints = $this->findConstraints();
        if (($constraints === null) || (count($constraints) == 0))
            return;

        $loginConfig = $this->getLoginConfig();
        if ($loginConfig === null) {
            $loginConfig = new LoginConfig("NONE", null, null, null);
            $this->setLoginConfig(loginConfig);
        }

        // Has a Realm been configured for us to authenticate against?
        if ($this->getRealm() === null) {
            $this->log->error("No Realm has been configured to authenticate against");
            return;
        }

        // Identify the class name of the Valve we should configure
        $authenticatorName = array_key_exists($loginConfig->getAuthMethod(), $this->authenticators) ?
                                $this->authenticators[$loginConfig->getAuthMethod()] : null;

        if ($authenticatorName === null) {
            $this->log->error("Cannot configure an authenticator for method ".$loginConfig->getAuthMethod());
            return;
        }

        // Instantiate and install an Authenticator of the requested class
        $authenticator = null;
        try {

            require_once("aas/authenticator/".$authenticatorName.".php");
            $authenticator = new $authenticatorName();
            $authenticator->setContainer($this);
            $authenticator->invoke($request, $response, $this);

        } catch (Exception $e) {
            $this->log->error("Cannot instantiate an authenticator of class ".$authenticatorName);
        }

    }


    /**
     * Adjust the URL pattern to begin with a leading slash, if appropriate
     * (i.e. we are running a servlet 2.2 application).  Otherwise, return
     * the specified URL pattern unchanged.
     *
     * @param urlPattern The URL pattern to be adjusted (if needed)
     *  and returned
     */
    protected function adjustURLPattern($urlPattern) {

        if ($urlPattern === null)
            return ($urlPattern);
        if (preg_match("/^\//", $urlPattern) || preg_match("/^\*\./", $urlPattern))
            return ($urlPattern);

        $this->log->warn("WARNING: URL pattern ".$urlPattern." must start with a '/' in Servlet 2.3");
        return ("/" . $urlPattern);

    }


    /**
     * Validate the syntax of a proposed <code>&lt;url-pattern&gt;</code>
     * for conformance with specification requirements.
     *
     * @param urlPattern URL pattern to be validated
     */
    private function validateURLPattern($urlPattern) {

        if ($urlPattern === null)
            return (false);
        if (preg_match("/^\*\./", $urlPattern)) {
            if (strpos($urlPattern, "/") === false)
                return (true);
            else
                return (false);
        }
        if (preg_match("/^\//", $urlPattern))
            return (true);
        else
            return (false);

    }
    
    
    // Error page processing
    public function processError(Request $request, Response &$response) {

        /* Only look for error pages when isError() is set.
         * isError() is set when response.sendError() is invoked. This
         * allows custom error pages without relying on default from
         * web.xml.
         */
        if (!$response->isError())
            return;

        $response->setSuspended(false);
        $t = $request->getAttribute(self::EXCEPTION_ATTR);
        if ($t !== null) {
            $this->throwable($request, $response, $t);
        } else {
            $this->status($request, $response);
        }
    }
    

    /**
     * Handle the HTTP status code (and corresponding message) generated
     * while processing the specified Request to produce the specified
     * Response.  Any exceptions that occur during generation of the error
     * report are logged and swallowed.
     *
     * Note: taken from StandardValveHost.java
     *
     * @param request The request being processed
     * @param response The response being generated
     */
    public function status(Request $request, Response &$response) {

        $statusCode = $response->getStatus();

        /* Only look for error pages when isError() is set.
         * isError() is set when response.sendError() is invoked. This
         * allows custom error pages without relying on default from
         * web.xml.
         */
        if (!$response->isError())
            return;

        $errorPage = $this->findErrorPage($statusCode);

        if ($errorPage !== null) {
            $response->setAppCommitted(false);
            $request->setAttribute(self::STATUS_CODE_ATTR,
                              intval($statusCode));

            $message = htmlentities($response->getMessage());
            if ($message === null)
                $message = "";
            $request->setAttribute(self::ERROR_MESSAGE_ATTR, $message);
            $request->setAttribute(self::DISPATCHER_REQUEST_PATH_ATTR, $errorPage->getLocation());
            $request->setAttribute(self::DISPATCHER_TYPE_ATTR, intval(self::ERROR));
            $request->setAttribute(self::EXCEPTION_PAGE_ATTR, $request->getRequestURI());
            if ($this->custom($request, $response, $errorPage)) {
                try {
//                    $response->flushBuffer();
                    $response->setSuspended(true);
                } catch (Exception $e) {
                    $this->log->warn("Exception Processing " . $errorPage ." : " . $e);
                }
            }
        }

    }
    
    
    /**
     * Handle the specified Throwable encountered while processing
     * the specified Request to produce the specified Response.  Any
     * exceptions that occur during generation of the exception report are
     * logged and swallowed.
     *
     * @param request   The request being processed
     * @param response  The response being generated
     * @param throwable The exception that occurred (which possibly wraps
     *                  a root cause exception
     */
    protected function throwable(Request $request, Response $response,
                             $throwable) {
        $context = $request->getContext();
        if ($context === null)
            return;

        $errorPage = $this->findErrorPage($context, get_class($throwable));

        if ($errorPage !== null) {
            
            $response->setAppCommitted(false);
            $request->setAttribute(self::STATUS_CODE_ATTR, Response::SC_INTERNAL_SERVER_ERROR);
            $request->setAttribute(self::ERROR_MESSAGE_ATTR, $throwable->getMessage());
            $request->setAttribute(self::EXCEPTION_ATTR, $throwable);
            $request->setAttribute(self::DISPATCHER_REQUEST_PATH_ATTR, $errorPage->getLocation());
            $request->setAttribute(self::DISPATCHER_TYPE_ATTR, intval(self::ERROR));
            $request->setAttribute(self::EXCEPTION_PAGE_ATTR, $request->getRequestURI());
            if ($this->custom($request, $response, $errorPage)) {
                try {
                    $response->flushBuffer();
//                    $response->setSuspended(true);
                    exit();
                } catch (Exception $e) {
                    $this->log->warn("Exception Processing " . $errorPage ." : " . $e);
                }
            }
          
        } else {
            // A custom error-page has not been defined for the exception
            // that was thrown during request processing. Check if an
            // error-page for error code 500 was specified and if so,
            // send that page back as the response.
            $response->setStatus(Response::SC_INTERNAL_SERVER_ERROR);
            // The response is an error
            $response->setError();

            $this->status($request, $response);
        }


    }


    /**
     * Handle an HTTP status code or Java exception by forwarding control
     * to the location included in the specified errorPage object.  It is
     * assumed that the caller has already recorded any request attributes
     * that are to be forwarded to this page.  Return <code>true</code> if
     * we successfully utilized the specified error page location, or
     * <code>false</code> if the default error report should be rendered.
     *
     * @param request The request being processed
     * @param response The response being generated
     * @param errorPage The errorPage directive we are obeying
     */
    protected function custom(Request &$request, Response &$response,
                             ErrorPage $errorPage) {

        if ($this->log->isDebugEnabled())
            $this->log->debug("Processing " . $errorPage);

        $request->setPathInfo($errorPage->getLocation());

        try {

            // Reset the response if possible (else IllegalStateException)
            //hres.reset();
            // Reset the response (keeping the real error code and message)
            $statusCodeObj =
                $request->getAttribute(self::STATUS_CODE_ATTR);
            $statusCode = intval($statusCodeObj);
            $message =
                $request->getAttribute(self::ERROR_MESSAGE_ATTR);
            $response->reset($statusCode, $message);

            // Forward control to the specified location
            // Todo: Implement RequestDispatcher
            ob_start();

            include($this->basePath.$errorPage->getLocation());

            $page = ob_get_contents();
            ob_clean();

            $response->getOutputBuffer()->write($page);

            // If we forward, the response is suspended again
            $response->setSuspended(false);

            // Indicate that we have successfully processed this custom page
            return (true);

        } catch (Throwable $t) {

            // Report our failure to process this custom page
            $this->log->error("Exception Processing " . $errorPage . " " . $t);
            return (false);

        }

    }


    // --------------------------------------------------------- Static methods


    /**
     * Loads a PHP file.  This is a wrapper for PHP's include() function.
     *
     * $filename must be the complete filename, including any
     * extension such as ".php".  Note that a security check is performed that
     * does not permit extended characters in the filename.  This method is
     * intended for loading Zend Framework files.
     *
     * If $dirs is a string or an array, it will search the directories
     * in the order supplied, and attempt to load the first matching file.
     *
     * If the file was not found in the $dirs, or if no $dirs were specified,
     * it will attempt to load it from PHP's include_path.
     *
     * If $once is TRUE, it will use include_once() instead of include().
     *
     * @param  string        $filename
     * @param  string|array  $dirs
     * @param  boolean       $once
     * @throws Zend_Exception
     * @return mixed
     */
    static public function loadFile($filename, $dirs=null, $once=false)
    {

        // security check
        if (preg_match('/[^a-z0-9\-_.]/i', $filename)) {
            throw new Exception('Security check: Illegal character in filename');
        }

        /**
         * Determine if the file is readable, either within just the include_path
         * or within the $dirs search list.
         */
        $filespec = $filename;
        if ($dirs === null) {
            $found = self::isReadable($filespec);
        } else {
            foreach ((array)$dirs as $dir) {
                $filespec = rtrim($dir, '\\/') . DIRECTORY_SEPARATOR . $filename;
                $found = self::isReadable($filespec);
                if ($found) {
                    break;
                }
            }
        }

        /**
         * Throw an exception if the file could not be located
         */
        if (!$found) {
            throw new Exception("File \"$filespec\" was not found.");
        }

        /**
         * Attempt to include() the file.
         *
         * include() is not prefixed with the @ operator because if
         * the file is loaded and contains a parse error, execution
         * will halt silently and this is difficult to debug.
         *
         * Always set display_errors = Off on production servers!
         */
        if ($once) {
            return include_once $filespec;
        } else {
            return $filespec ;
        }
    }


    /**
     * Loads a class from a PHP file.  The filename must be formatted
     * as "$class.php".
     *
     * If $dirs is a string or an array, it will search the directories
     * in the order supplied, and attempt to load the first matching file.
     *
     * If $dirs is null, it will split the class name at underscores to
     * generate a path hierarchy (e.g., "Zend_Example_Class" will map
     * to "Zend/Example/Class.php").
     *
     * If the file was not found in the $dirs, or if no $dirs were specified,
     * it will attempt to load it from PHP's include_path.
     *
     * @param string $class
     * @param string|array $dirs
     * @throws Zend_Exception
     * @return void
     */
    static public function loadClass($class, $dirs = null)
    {
        if (class_exists($class, false)) {
            return;
        }

        // autodiscover the path from the class name
        $path = str_replace('_', DIRECTORY_SEPARATOR, $class);
        if ($dirs === null && $path != $class) {
            // use the autodiscovered path
            $dirs = dirname($path);
            $file = basename($path) . '.php';
        } else {
            $file = $class . '.php';
        }

        self::loadFile($file, $dirs, true);

        if (!class_exists($class, false)) {
            throw new Zend_Exception("File \"$file\" was loaded "
                               . "but class \"$class\" was not found within.");
        }
    }


    static public function getFilePath($filename)
    {

        if (is_readable($filename)) {
            return $filename;
        }

        $path = get_include_path();
        $dirs = explode(PATH_SEPARATOR, $path);

        foreach ($dirs as $dir) {
            // No need to check against current dir -- already checked
            if ('.' == $dir) {
                continue;
            }

            if (is_readable($dir . DIRECTORY_SEPARATOR . $filename)) {
                return $dir . DIRECTORY_SEPARATOR . $filename;
            }
        }

        return $filename;
    }


    /**
     * Returns TRUE if the $filename is readable, or FALSE otherwise.  This
     * function uses the PHP include_path, where PHP's is_readable() does not.
     *
     * @param string $filename
     * @return boolean
     */
    static public function isReadable($filename)
    {
        
        if (is_readable($filename)) {
            return true;
        }

        $path = get_include_path();
        $dirs = explode(PATH_SEPARATOR, $path);

        foreach ($dirs as $dir) {
            // No need to check against current dir -- already checked
            if ('.' == $dir) {
                continue;
            }

            if (is_readable($dir . DIRECTORY_SEPARATOR . $filename)) {
                return true;
            }
        }

        return false;
    }


    /**
     * Debug helper function.  This is a wrapper for var_dump() that adds
     * the <pre /> tags, cleans up newlines and indents, and runs
     * htmlentities() before output.
     *
     * @param  mixed  $var The variable to dump.
     * @param  string $label An optional label.
     * @return string
     */
    static public function dump($var, $label=null, $echo=true)
    {
        // format the label
        $label = ($label===null) ? '' : rtrim($label) . ' ';

        // var_dump the variable into a buffer and keep the output
        ob_start();
        var_dump($var);
        $output = ob_get_clean();

        // neaten the newlines and indents
        $output = preg_replace("/\]\=\>\n(\s+)/m", "] => ", $output);
        if (PHP_SAPI == 'cli') {
            $output = PHP_EOL . $label
                    . PHP_EOL . $output
                    . PHP_EOL;
        } else {
            $output = '<pre>'
                    . $label
                    . htmlentities($output, ENT_QUOTES)
                    . '</pre>';
        }

        if ($echo) {
            echo($output);
        }
        return $output;
    }

}
?>